MBL
Go / avito / Тестовое задание / Пять разных идемпотентностей в одном проекте
Go сложный

Пять разных идемпотентностей в одном проекте

avitoidempotencykafkapostgres

«Идемпотентность» на собесе редко спрашивают одним вопросом — обычно она всплывает в разборе конкретной ручки («а если этот запрос продублируется?»). В «Avito.Кухне» пять разных точек входа решают одну и ту же проблему пятью разными, не взаимозаменяемыми механизмами. Разложить их по полочкам — сильный ответ на любой вопрос про дублирование запросов.

1. Создание заказа: клиентский Idempotency-Key

Проблема: клиент делает POST /orders, не получает ответ вовремя (таймаут сети), повторяет запрос. Без защиты — два заказа, двойное списание остатка.

Механизм: опциональный заголовок Idempotency-Key, колонка orders.client_idempotency_key, уникальность не глобальная, а UNIQUE(user_id, client_idempotency_key):

ALTER TABLE orders ADD COLUMN client_idempotency_key text;
CREATE UNIQUE INDEX ON orders(user_id, client_idempotency_key)
  WHERE client_idempotency_key IS NOT NULL;

Если ключ присутствует и заказ с таким ключом у этого user_id уже есть — возвращается существующий заказ (200), новый не создаётся. Почему не глобальная уникальность: глобальный UNIQUE(client_idempotency_key) позволил бы одному клиенту (случайно или специально) занять ключ навсегда для всех остальных пользователей — чужой повторный запрос с тем же ключом молча получил бы не свой заказ.

2. Callback от заведения: серверный Idempotency-Key + аудит-таблица

Проблема: Kafka-коллбэк статуса может продублироваться (at-least-once доставка).

Механизм: таблица order_status_events (аудит + идемпотентность), частичный уникальный индекс:

CREATE UNIQUE INDEX ON order_status_events(order_id, idempotency_key)
  WHERE idempotency_key IS NOT NULL;

Establishment-service формирует детерминированный ключ "{kitchen_order_id}:{status}" — не случайный. Разница видна сразу на двух подходах к генерации ключа:

Случайный ключ
func idempotencyKey() string {
	return uuid.New().String()
}

// каждый вызов /advance с тем же action
// получает НОВЫЙ ключ - уникальный индекс
// не увидит связи между попытками, дубль
// события возможен при повторе после 502
Детерминированный ключ
func idempotencyKey(orderID, status string) string {
	return fmt.Sprintf("%s:%s", orderID, status)
}

// повторный /advance {"action":"accept"} на тот
// же заказ всегда возвращает тот же ключ -
// уникальный индекс сам не даёт вставить дубль

Повторный вызов /advance с тем же действием на том же заказе всегда даёт тот же ключ, значит повторная вставка события в order_status_events с тем же (order_id, idempotency_key) конфликтует с уникальным индексом и не создаёт дубль.

3. Смена статуса: самоидемпотентный переход (без ключа вообще)

Механизмы 1 и 2 защищают от последовательных повторов через ключ. Они не защищают от параллельных запросов без ключа — два одновременных rejected-коллбэка на один заказ без Idempotency-Key оба успевают прочитать текущий статус до того, как любой из них зафиксирует изменение, и оба проходят валидацию перехода. Результат — остаток возвращается дважды.

Механизм: SELECT status FROM orders WHERE id = $1 FOR UPDATE перед любой проверкой допустимости перехода:

order, _ := repo.GetForUpdate(ctx, orderID) // держит блокировку строки

if order.Status == newStatus {
    return order, nil // повтор того же статуса — no-op, без побочных эффектов
}
if !domain.CanTransition(order.Status, newStatus) {
    return domain.Order{}, domain.ErrInvalidTransition
}
// применяем переход + событие + компенсация остатка — всё внутри той же
// транзакции, что держит блокировку

Это идемпотентность по построению, не по ключу: если запрошенный статус совпадает с текущим — no-op, вне зависимости от того, был ли передан Idempotency-Key. Именно этот механизм защищает и от параллельных коллбэков, и от параллельных POST /orders/{id}/cancel одного и того же заказа — оба случая сводятся к «два одновременных запроса пытаются применить один и тот же переход».

Механизм 2 (ключ + уникальный индекс) и механизм 3 (блокировка + сравнение статуса) решают разные срезы одной проблемы: ключ отличает «два разных легитимных события» от «дубль одного и того же события», блокировка защищает от гонки за применение одного и того же перехода. Оба нужны одновременно, ни один не заменяет другой.

4. Kafka consumer в kitchen-service: at-least-once через commit-after-success

Механизм: offset коммитится после успешной обработки сообщения, не до:

isDomainErr, err := handler(ctx, msg.Key, msg.Value)
if err != nil {
    if isDomainErr {
        toDLQ(ctx, msg, err)
        // только после успешной публикации в DLQ — коммит offset
    }
    // транзиентная ошибка — retry с backoff, offset не коммитится
    return
}
consumer.CommitMessages(ctx, msg) // offset двигается только тут

Если процесс падает после того, как переход статуса применился в БД, но до commit offset — при рестарте сообщение придёт снова. Это at-least-once, не at-most-once (сообщение может обработаться больше одного раза, но никогда не потеряется молча). Повторная обработка безопасна — она попадает ровно в механизм 3 (сравнение order.Status == newStatus → no-op).

5. /advance в establishment-service: идемпотентный повтор как путь восстановления после 502

Если PublishStatusUpdate падает, локальный статус в establishment-service уже применён (не откатывается), но HTTP-ответ — 502 publish_failed, не 200. Человек, увидевший 502 в демо-интерфейсе, просто жмёт кнопку ещё раз. Повторный /advance с тем же action: - не меняет локальный статус повторно (уже равен целевому — гейт из пункта, аналогичного механизму 3, только на стороне establishment-service); - пробует публикацию заново с тем же детерминированным idempotency_key из пункта 2 — то есть даже если первая попытка успела дойти до Kafka, но ответ потерялся, повторная публикация не создаёт дублирующее событие на стороне kitchen.

Итоговая таблица

Точка входа От чего защищает Механизм
POST /orders Дубль заказа при повторном клиентском запросе Idempotency-Key + UNIQUE(user_id, key)
Kafka-коллбэк статуса Дубль события при повторной доставке Детерминированный Idempotency-Key + UNIQUE(order_id, key)
Любая смена статуса Гонка параллельных запросов без ключа SELECT ... FOR UPDATE + сравнение с текущим статусом
Kafka consumer (kitchen) Потеря сообщения при падении процесса Commit offset после успешной обработки (at-least-once)
/advance (establishment) 502 оставляет систему в непонятном состоянии Повтор того же action безопасен по построению (детерминированный ключ + гейт по локальному статусу)

Готовый ответ на «а как ты вообще думал про идемпотентность в проекте» — не «добавил Idempotency-Key», а именно эта таблица: разные точки входа, разные угрозы, разные (и при этом совместимые) механизмы.

Самопроверка 0 / 3
Могу назвать все точки входа в системе, которые нуждаются в идемпотентности
Понимаю разницу между Idempotency-Key и самоидемпотентным переходом статуса
Знаю, почему commit offset после обработки — это at-least-once, а не at-most-once
Как усвоено?